// Three.js - https://github.com/mrdoob/three.js/
// RoundedBoxGeometry - https://github.com/pailhead/three-rounded-box

const animationEngine = (() => {
    let uniqueID = 0;

    class AnimationEngine {
        constructor() {
            this.ids = [];
            this.animations = {};
            this.update = this.update.bind(this);
            this.raf = 0;
            this.time = 0;
        }

        update() {
            const now = performance.now();
            const delta = now - this.time;
            this.time = now;

            let i = this.ids.length;

            this.raf = i ? requestAnimationFrame(this.update) : 0;

            while (i--)
                this.animations[this.ids[i]] &&
                this.animations[this.ids[i]].update(delta);
        }

        add(animation) {
            animation.id = uniqueID++;

            this.ids.push(animation.id);
            this.animations[animation.id] = animation;

            if (this.raf !== 0) return;

            this.time = performance.now();
            this.raf = requestAnimationFrame(this.update);
        }

        remove(animation) {
            const index = this.ids.indexOf(animation.id);

            if (index < 0) return;

            this.ids.splice(index, 1);
            delete this.animations[animation.id];
            animation = null;
        }
    }

    return new AnimationEngine();
})();

class Animation {
    constructor(start) {
        if (start === true) this.start();
    }

    start() {
        animationEngine.add(this);
    }

    stop() {
        animationEngine.remove(this);
    }

    update(delta) {}
}

class World extends Animation {
    constructor(game) {
        super(true);

        this.game = game;

        this.container = this.game.dom.game;
        this.scene = new THREE.Scene();

        this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
        this.renderer.setPixelRatio(window.devicePixelRatio);
        this.container.appendChild(this.renderer.domElement);

        this.camera = new THREE.PerspectiveCamera(2, 1, 0.1, 10000);

        this.stage = { width: 2, height: 3 };
        this.fov = 10;

        this.createLights();

        this.onResize = [];

        this.resize();
        window.addEventListener("resize", () => this.resize(), false);
    }

    update() {
        this.renderer.render(this.scene, this.camera);
    }

    resize() {
        this.width = this.container.offsetWidth;
        this.height = this.container.offsetHeight;

        this.renderer.setSize(this.width, this.height);

        this.camera.fov = this.fov;
        this.camera.aspect = this.width / this.height;

        const aspect = this.stage.width / this.stage.height;
        const fovRad = this.fov * THREE.Math.DEG2RAD;

        let distance =
            aspect < this.camera.aspect ?
            this.stage.height / 2 / Math.tan(fovRad / 2) :
            this.stage.width / this.camera.aspect / (2 * Math.tan(fovRad / 2));

        distance *= 0.5;

        this.camera.position.set(distance, distance, distance);
        this.camera.lookAt(this.scene.position);
        this.camera.updateProjectionMatrix();

        const docFontSize =
            aspect < this.camera.aspect ?
            (this.height / 100) * aspect :
            this.width / 100;

        document.documentElement.style.fontSize = docFontSize + "px";

        if (this.onResize) this.onResize.forEach((cb) => cb());
    }

    createLights() {
        this.lights = {
            holder: new THREE.Object3D(),
            ambient: new THREE.AmbientLight(0xffffff, 0.69),
            front: new THREE.DirectionalLight(0xffffff, 0.36),
            back: new THREE.DirectionalLight(0xffffff, 0.19),
        };

        this.lights.front.position.set(1.5, 5, 3);
        this.lights.back.position.set(-1.5, -5, -3);

        this.lights.holder.add(this.lights.ambient);
        this.lights.holder.add(this.lights.front);
        this.lights.holder.add(this.lights.back);

        this.scene.add(this.lights.holder);
    }

    enableShadows() {
        this.renderer.shadowMap.enabled = true;
        this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;

        this.lights.front.castShadow = true;

        this.lights.front.shadow.mapSize.width = 512;
        this.lights.front.shadow.mapSize.height = 512;

        var d = 1.5;

        this.lights.front.shadow.camera.left = -d;
        this.lights.front.shadow.camera.right = d;
        this.lights.front.shadow.camera.top = d;
        this.lights.front.shadow.camera.bottom = -d;

        this.lights.front.shadow.camera.near = 1;
        this.lights.front.shadow.camera.far = 9;

        this.game.cube.holder.traverse((node) => {
            if (node instanceof THREE.Mesh) {
                node.castShadow = true;
                node.receiveShadow = true;
            }
        });
    }
}

function RoundedBoxGeometry(size, radius, radiusSegments) {
    THREE.BufferGeometry.call(this);

    this.type = "RoundedBoxGeometry";

    radiusSegments = !isNaN(radiusSegments) ?
        Math.max(1, Math.floor(radiusSegments)) :
        1;

    var width, height, depth;

    width = height = depth = size;
    radius = size * radius;

    radius = Math.min(
        radius,
        Math.min(width, Math.min(height, Math.min(depth))) / 2
    );

    var edgeHalfWidth = width / 2 - radius;
    var edgeHalfHeight = height / 2 - radius;
    var edgeHalfDepth = depth / 2 - radius;

    this.parameters = {
        width: width,
        height: height,
        depth: depth,
        radius: radius,
        radiusSegments: radiusSegments,
    };

    var rs1 = radiusSegments + 1;
    var totalVertexCount = (rs1 * radiusSegments + 1) << 3;

    var positions = new THREE.BufferAttribute(
        new Float32Array(totalVertexCount * 3),
        3
    );
    var normals = new THREE.BufferAttribute(
        new Float32Array(totalVertexCount * 3),
        3
    );

    var cornerVerts = [],
        cornerNormals = [],
        normal = new THREE.Vector3(),
        vertex = new THREE.Vector3(),
        vertexPool = [],
        normalPool = [],
        indices = [];
    var lastVertex = rs1 * radiusSegments,
        cornerVertNumber = rs1 * radiusSegments + 1;
    doVertices();
    doFaces();
    doCorners();
    doHeightEdges();
    doWidthEdges();
    doDepthEdges();

    function doVertices() {
        var cornerLayout = [
            new THREE.Vector3(1, 1, 1),
            new THREE.Vector3(1, 1, -1),
            new THREE.Vector3(-1, 1, -1),
            new THREE.Vector3(-1, 1, 1),
            new THREE.Vector3(1, -1, 1),
            new THREE.Vector3(1, -1, -1),
            new THREE.Vector3(-1, -1, -1),
            new THREE.Vector3(-1, -1, 1),
        ];

        for (var j = 0; j < 8; j++) {
            cornerVerts.push([]);
            cornerNormals.push([]);
        }

        var PIhalf = Math.PI / 2;
        var cornerOffset = new THREE.Vector3(
            edgeHalfWidth,
            edgeHalfHeight,
            edgeHalfDepth
        );

        for (var y = 0; y <= radiusSegments; y++) {
            var v = y / radiusSegments;
            var va = v * PIhalf;
            var cosVa = Math.cos(va);
            var sinVa = Math.sin(va);

            if (y == radiusSegments) {
                vertex.set(0, 1, 0);
                var vert = vertex.clone().multiplyScalar(radius).add(cornerOffset);
                cornerVerts[0].push(vert);
                vertexPool.push(vert);
                var norm = vertex.clone();
                cornerNormals[0].push(norm);
                normalPool.push(norm);
                continue;
            }

            for (var x = 0; x <= radiusSegments; x++) {
                var u = x / radiusSegments;
                var ha = u * PIhalf;
                vertex.x = cosVa * Math.cos(ha);
                vertex.y = sinVa;
                vertex.z = cosVa * Math.sin(ha);

                var vert = vertex.clone().multiplyScalar(radius).add(cornerOffset);
                cornerVerts[0].push(vert);
                vertexPool.push(vert);

                var norm = vertex.clone().normalize();
                cornerNormals[0].push(norm);
                normalPool.push(norm);
            }
        }

        for (var i = 1; i < 8; i++) {
            for (var j = 0; j < cornerVerts[0].length; j++) {
                var vert = cornerVerts[0][j].clone().multiply(cornerLayout[i]);
                cornerVerts[i].push(vert);
                vertexPool.push(vert);

                var norm = cornerNormals[0][j].clone().multiply(cornerLayout[i]);
                cornerNormals[i].push(norm);
                normalPool.push(norm);
            }
        }
    }

    function doCorners() {
        var flips = [true, false, true, false, false, true, false, true];

        var lastRowOffset = rs1 * (radiusSegments - 1);

        for (var i = 0; i < 8; i++) {
            var cornerOffset = cornerVertNumber * i;

            for (var v = 0; v < radiusSegments - 1; v++) {
                var r1 = v * rs1;
                var r2 = (v + 1) * rs1;

                for (var u = 0; u < radiusSegments; u++) {
                    var u1 = u + 1;
                    var a = cornerOffset + r1 + u;
                    var b = cornerOffset + r1 + u1;
                    var c = cornerOffset + r2 + u;
                    var d = cornerOffset + r2 + u1;

                    if (!flips[i]) {
                        indices.push(a);
                        indices.push(b);
                        indices.push(c);

                        indices.push(b);
                        indices.push(d);
                        indices.push(c);
                    } else {
                        indices.push(a);
                        indices.push(c);
                        indices.push(b);

                        indices.push(b);
                        indices.push(c);
                        indices.push(d);
                    }
                }
            }

            for (var u = 0; u < radiusSegments; u++) {
                var a = cornerOffset + lastRowOffset + u;
                var b = cornerOffset + lastRowOffset + u + 1;
                var c = cornerOffset + lastVertex;

                if (!flips[i]) {
                    indices.push(a);
                    indices.push(b);
                    indices.push(c);
                } else {
                    indices.push(a);
                    indices.push(c);
                    indices.push(b);
                }
            }
        }
    }

    function doFaces() {
        var a = lastVertex;
        var b = lastVertex + cornerVertNumber;
        var c = lastVertex + cornerVertNumber * 2;
        var d = lastVertex + cornerVertNumber * 3;

        indices.push(a);
        indices.push(b);
        indices.push(c);
        indices.push(a);
        indices.push(c);
        indices.push(d);

        a = lastVertex + cornerVertNumber * 4;
        b = lastVertex + cornerVertNumber * 5;
        c = lastVertex + cornerVertNumber * 6;
        d = lastVertex + cornerVertNumber * 7;

        indices.push(a);
        indices.push(c);
        indices.push(b);
        indices.push(a);
        indices.push(d);
        indices.push(c);

        a = 0;
        b = cornerVertNumber;
        c = cornerVertNumber * 4;
        d = cornerVertNumber * 5;

        indices.push(a);
        indices.push(c);
        indices.push(b);
        indices.push(b);
        indices.push(c);
        indices.push(d);

        a = cornerVertNumber * 2;
        b = cornerVertNumber * 3;
        c = cornerVertNumber * 6;
        d = cornerVertNumber * 7;

        indices.push(a);
        indices.push(c);
        indices.push(b);
        indices.push(b);
        indices.push(c);
        indices.push(d);

        a = radiusSegments;
        b = radiusSegments + cornerVertNumber * 3;
        c = radiusSegments + cornerVertNumber * 4;
        d = radiusSegments + cornerVertNumber * 7;

        indices.push(a);
        indices.push(b);
        indices.push(c);
        indices.push(b);
        indices.push(d);
        indices.push(c);

        a = radiusSegments + cornerVertNumber;
        b = radiusSegments + cornerVertNumber * 2;
        c = radiusSegments + cornerVertNumber * 5;
        d = radiusSegments + cornerVertNumber * 6;

        indices.push(a);
        indices.push(c);
        indices.push(b);
        indices.push(b);
        indices.push(c);
        indices.push(d);
    }

    function doHeightEdges() {
        for (var i = 0; i < 4; i++) {
            var cOffset = i * cornerVertNumber;
            var cRowOffset = 4 * cornerVertNumber + cOffset;
            var needsFlip = i & (1 === 1);

            for (var u = 0; u < radiusSegments; u++) {
                var u1 = u + 1;
                var a = cOffset + u;
                var b = cOffset + u1;
                var c = cRowOffset + u;
                var d = cRowOffset + u1;

                if (!needsFlip) {
                    indices.push(a);
                    indices.push(b);
                    indices.push(c);
                    indices.push(b);
                    indices.push(d);
                    indices.push(c);
                } else {
                    indices.push(a);
                    indices.push(c);
                    indices.push(b);
                    indices.push(b);
                    indices.push(c);
                    indices.push(d);
                }
            }
        }
    }

    function doDepthEdges() {
        var cStarts = [0, 2, 4, 6];
        var cEnds = [1, 3, 5, 7];

        for (var i = 0; i < 4; i++) {
            var cStart = cornerVertNumber * cStarts[i];
            var cEnd = cornerVertNumber * cEnds[i];

            var needsFlip = 1 >= i;

            for (var u = 0; u < radiusSegments; u++) {
                var urs1 = u * rs1;
                var u1rs1 = (u + 1) * rs1;

                var a = cStart + urs1;
                var b = cStart + u1rs1;
                var c = cEnd + urs1;
                var d = cEnd + u1rs1;

                if (needsFlip) {
                    indices.push(a);
                    indices.push(c);
                    indices.push(b);
                    indices.push(b);
                    indices.push(c);
                    indices.push(d);
                } else {
                    indices.push(a);
                    indices.push(b);
                    indices.push(c);
                    indices.push(b);
                    indices.push(d);
                    indices.push(c);
                }
            }
        }
    }

    function doWidthEdges() {
        var end = radiusSegments - 1;

        var cStarts = [0, 1, 4, 5];
        var cEnds = [3, 2, 7, 6];
        var needsFlip = [0, 1, 1, 0];

        for (var i = 0; i < 4; i++) {
            var cStart = cStarts[i] * cornerVertNumber;
            var cEnd = cEnds[i] * cornerVertNumber;

            for (var u = 0; u <= end; u++) {
                var a = cStart + radiusSegments + u * rs1;
                var b =
                    cStart +
                    (u != end ? radiusSegments + (u + 1) * rs1 : cornerVertNumber - 1);

                var c = cEnd + radiusSegments + u * rs1;
                var d =
                    cEnd +
                    (u != end ? radiusSegments + (u + 1) * rs1 : cornerVertNumber - 1);

                if (!needsFlip[i]) {
                    indices.push(a);
                    indices.push(b);
                    indices.push(c);
                    indices.push(b);
                    indices.push(d);
                    indices.push(c);
                } else {
                    indices.push(a);
                    indices.push(c);
                    indices.push(b);
                    indices.push(b);
                    indices.push(c);
                    indices.push(d);
                }
            }
        }
    }

    var index = 0;

    for (var i = 0; i < vertexPool.length; i++) {
        positions.setXYZ(index, vertexPool[i].x, vertexPool[i].y, vertexPool[i].z);

        normals.setXYZ(index, normalPool[i].x, normalPool[i].y, normalPool[i].z);

        index++;
    }

    this.setIndex(new THREE.BufferAttribute(new Uint16Array(indices), 1));
    this.addAttribute("position", positions);
    this.addAttribute("normal", normals);
}

RoundedBoxGeometry.prototype = Object.create(THREE.BufferGeometry.prototype);
RoundedBoxGeometry.constructor = RoundedBoxGeometry;

function RoundedPlaneGeometry(size, radius, depth) {
    var x, y, width, height;

    x = y = -size / 2;
    width = height = size;
    radius = size * radius;

    const shape = new THREE.Shape();

    shape.moveTo(x, y + radius);
    shape.lineTo(x, y + height - radius);
    shape.quadraticCurveTo(x, y + height, x + radius, y + height);
    shape.lineTo(x + width - radius, y + height);
    shape.quadraticCurveTo(x + width, y + height, x + width, y + height - radius);
    shape.lineTo(x + width, y + radius);
    shape.quadraticCurveTo(x + width, y, x + width - radius, y);
    shape.lineTo(x + radius, y);
    shape.quadraticCurveTo(x, y, x, y + radius);

    const geometry = new THREE.ExtrudeBufferGeometry(shape, {
        depth: depth,
        bevelEnabled: false,
        curveSegments: 3,
    });

    return geometry;
}

class Cube {
    constructor(game) {
        this.game = game;

        this.geometry = {
            pieceSize: 1 / 3,
            pieceCornerRadius: 0.12,
            edgeCornerRoundness: 0.15,
            edgeScale: 0.82,
            edgeDepth: 0.01,
        };

        this.holder = new THREE.Object3D();
        this.object = new THREE.Object3D();
        this.animator = new THREE.Object3D();
        this.holder.add(this.animator);
        this.animator.add(this.object);

        this.cubes = [];

        this.generatePositions();
        this.generateModel();

        this.pieces.forEach((piece) => {
            this.cubes.push(piece.userData.cube);
            this.object.add(piece);
        });

        this.holder.traverse((node) => {
            if (node.frustumCulled) node.frustumCulled = false;
        });

        this.game.world.scene.add(this.holder);
    }

    reset() {
        this.game.controls.edges.rotation.set(0, 0, 0);

        this.holder.rotation.set(0, 0, 0);
        this.object.rotation.set(0, 0, 0);
        this.animator.rotation.set(0, 0, 0);

        this.pieces.forEach((piece) => {
            piece.position.copy(piece.userData.start.position);
            piece.rotation.copy(piece.userData.start.rotation);
        });
    }

    generatePositions() {
        let x, y, z;

        this.positions = [];

        for (x = 0; x < 3; x++) {
            for (y = 0; y < 3; y++) {
                for (z = 0; z < 3; z++) {
                    let position = new THREE.Vector3(x - 1, y - 1, z - 1);
                    let edges = [];

                    if (x == 0) edges.push(0);
                    if (x == 2) edges.push(1);
                    if (y == 0) edges.push(2);
                    if (y == 2) edges.push(3);
                    if (z == 0) edges.push(4);
                    if (z == 2) edges.push(5);

                    position.edges = edges;

                    this.positions.push(position);
                }
            }
        }
    }

    generateModel() {
        this.pieces = [];
        this.edges = [];

        const pieceSize = 1 / 3;

        const mainMaterial = new THREE.MeshLambertMaterial();

        const pieceMesh = new THREE.Mesh(
            new RoundedBoxGeometry(pieceSize, this.geometry.pieceCornerRadius, 3),
            mainMaterial.clone()
        );

        const edgeGeometry = RoundedPlaneGeometry(
            pieceSize,
            this.geometry.edgeCornerRoundness,
            this.geometry.edgeDepth
        );

        this.positions.forEach((position, index) => {
            const piece = new THREE.Object3D();
            const pieceCube = pieceMesh.clone();
            const pieceEdges = [];

            piece.position.copy(position.clone().divideScalar(3));
            piece.add(pieceCube);
            piece.name = index;
            piece.edgesName = "";

            position.edges.forEach((position) => {
                const edge = new THREE.Mesh(edgeGeometry, mainMaterial.clone());
                const name = ["L", "R", "D", "U", "B", "F"][position];
                const distance = pieceSize / 2;

                edge.position.set(
                    distance * [-1, 1, 0, 0, 0, 0][position],
                    distance * [0, 0, -1, 1, 0, 0][position],
                    distance * [0, 0, 0, 0, -1, 1][position]
                );

                edge.rotation.set(
                    (Math.PI / 2) * [0, 0, 1, -1, 0, 0][position],
                    (Math.PI / 2) * [-1, 1, 0, 0, 2, 0][position],
                    0
                );

                edge.scale.set(
                    this.geometry.edgeScale,
                    this.geometry.edgeScale,
                    this.geometry.edgeScale
                );

                edge.name = name;

                piece.add(edge);
                pieceEdges.push(name);
                this.edges.push(edge);
            });

            piece.userData.edges = pieceEdges;
            piece.userData.cube = pieceCube;

            piece.userData.start = {
                position: piece.position.clone(),
                rotation: piece.rotation.clone(),
            };

            this.pieces.push(piece);
        });
    }
}

const Easing = {
    Power: {
        In: (power) => {
            power = Math.round(power || 1);

            return (t) => Math.pow(t, power);
        },

        Out: (power) => {
            power = Math.round(power || 1);

            return (t) => 1 - Math.abs(Math.pow(t - 1, power));
        },

        InOut: (power) => {
            power = Math.round(power || 1);

            return (t) =>
                t < 0.5 ?
                Math.pow(t * 2, power) / 2 :
                (1 - Math.abs(Math.pow(t * 2 - 1 - 1, power))) / 2 + 0.5;
        },
    },

    Sine: {
        In: () => (t) => 1 + Math.sin((Math.PI / 2) * t - Math.PI / 2),

        Out: () => (t) => Math.sin((Math.PI / 2) * t),

        InOut: () => (t) => (1 + Math.sin(Math.PI * t - Math.PI / 2)) / 2,
    },

    Back: {
        Out: (s) => {
            s = s || 1.70158;

            return (t) => {
                return (t -= 1) * t * ((s + 1) * t + s) + 1;
            };
        },

        In: (s) => {
            s = s || 1.70158;

            return (t) => {
                return t * t * ((s + 1) * t - s);
            };
        },
    },

    Elastic: {
        Out: (amplitude, period) => {
            let PI2 = Math.PI * 2;

            let p1 = amplitude >= 1 ? amplitude : 1;
            let p2 = (period || 0.3) / (amplitude < 1 ? amplitude : 1);
            let p3 = (p2 / PI2) * (Math.asin(1 / p1) || 0);

            p2 = PI2 / p2;

            return (t) => {
                return p1 * Math.pow(2, -10 * t) * Math.sin((t - p3) * p2) + 1;
            };
        },
    },
};

class Tween extends Animation {
    constructor(options) {
        super(false);

        this.duration = options.duration || 500;
        this.easing = options.easing || ((t) => t);
        this.onUpdate = options.onUpdate || (() => {});
        this.onComplete = options.onComplete || (() => {});

        this.delay = options.delay || false;
        this.yoyo = options.yoyo ? false : null;

        this.progress = 0;
        this.value = 0;
        this.delta = 0;

        this.getFromTo(options);

        if (this.delay) setTimeout(() => super.start(), this.delay);
        else super.start();

        this.onUpdate(this);
    }

    update(delta) {
        const old = this.value * 1;
        const direction = this.yoyo === true ? -1 : 1;

        this.progress += (delta / this.duration) * direction;

        this.value = this.easing(this.progress);
        this.delta = this.value - old;

        if (this.values !== null) this.updateFromTo();

        if (this.yoyo !== null) this.updateYoyo();
        else if (this.progress <= 1) this.onUpdate(this);
        else {
            this.progress = 1;
            this.value = 1;
            this.onUpdate(this);
            this.onComplete(this);
            super.stop();
        }
    }

    updateYoyo() {
        if (this.progress > 1 || this.progress < 0) {
            this.value = this.progress = this.progress > 1 ? 1 : 0;
            this.yoyo = !this.yoyo;
        }

        this.onUpdate(this);
    }

    updateFromTo() {
        this.values.forEach((key) => {
            this.target[key] =
                this.from[key] + (this.to[key] - this.from[key]) * this.value;
        });
    }

    getFromTo(options) {
        if (!options.target || !options.to) {
            this.values = null;
            return;
        }

        this.target = options.target || null;
        this.from = options.from || {};
        this.to = options.to || null;
        this.values = [];

        if (Object.keys(this.from).length < 1)
            Object.keys(this.to).forEach((key) => {
                this.from[key] = this.target[key];
            });

        Object.keys(this.to).forEach((key) => {
            this.values.push(key);
        });
    }
}

window.addEventListener("touchmove", () => {});
document.addEventListener(
    "touchmove",
    (event) => {
        event.preventDefault();
    }, { passive: false }
);

class Draggable {
    constructor(element, options) {
        this.position = {
            current: new THREE.Vector2(),
            start: new THREE.Vector2(),
            delta: new THREE.Vector2(),
            old: new THREE.Vector2(),
            drag: new THREE.Vector2(),
        };

        this.options = Object.assign({
                calcDelta: false,
            },
            options || {}
        );

        this.element = element;
        this.touch = null;

        this.drag = {
            start: (event) => {
                if (event.type == "mousedown" && event.which != 1) return;
                if (event.type == "touchstart" && event.touches.length > 1) return;

                this.getPositionCurrent(event);

                if (this.options.calcDelta) {
                    this.position.start = this.position.current.clone();
                    this.position.delta.set(0, 0);
                    this.position.drag.set(0, 0);
                }

                this.touch = event.type == "touchstart";

                this.onDragStart(this.position);

                window.addEventListener(
                    this.touch ? "touchmove" : "mousemove",
                    this.drag.move,
                    false
                );
                window.addEventListener(
                    this.touch ? "touchend" : "mouseup",
                    this.drag.end,
                    false
                );
            },

            move: (event) => {
                if (this.options.calcDelta) {
                    this.position.old = this.position.current.clone();
                }

                this.getPositionCurrent(event);

                if (this.options.calcDelta) {
                    this.position.delta = this.position.current
                        .clone()
                        .sub(this.position.old);
                    this.position.drag = this.position.current
                        .clone()
                        .sub(this.position.start);
                }

                this.onDragMove(this.position);
            },

            end: (event) => {
                this.getPositionCurrent(event);

                this.onDragEnd(this.position);

                window.removeEventListener(
                    this.touch ? "touchmove" : "mousemove",
                    this.drag.move,
                    false
                );
                window.removeEventListener(
                    this.touch ? "touchend" : "mouseup",
                    this.drag.end,
                    false
                );
            },
        };

        this.onDragStart = () => {};
        this.onDragMove = () => {};
        this.onDragEnd = () => {};

        this.enable();

        return this;
    }

    enable() {
        this.element.addEventListener("touchstart", this.drag.start, false);
        this.element.addEventListener("mousedown", this.drag.start, false);

        return this;
    }

    disable() {
        this.element.removeEventListener("touchstart", this.drag.start, false);
        this.element.removeEventListener("mousedown", this.drag.start, false);

        return this;
    }

    getPositionCurrent(event) {
        const dragEvent = event.touches ?
            event.touches[0] || event.changedTouches[0] :
            event;

        this.position.current.set(dragEvent.pageX, dragEvent.pageY);
    }

    convertPosition(position) {
        position.x = (position.x / this.element.offsetWidth) * 2 - 1;
        position.y = -((position.y / this.element.offsetHeight) * 2 - 1);

        return position;
    }
}

const STILL = 0;
const PREPARING = 1;
const ROTATING = 2;
const ANIMATING = 3;

class Controls {
    constructor(game) {
        this.game = game;

        this.flipConfig = 0;

        this.flipEasings = [
            Easing.Power.Out(3),
            Easing.Sine.Out(),
            Easing.Back.Out(2),
        ];
        this.flipSpeeds = [125, 200, 350];

        this.raycaster = new THREE.Raycaster();

        const helperMaterial = new THREE.MeshBasicMaterial({
            depthWrite: false,
            transparent: true,
            opacity: 0,
            color: 0x0033ff,
        });

        this.group = new THREE.Object3D();
        this.game.cube.object.add(this.group);

        this.helper = new THREE.Mesh(
            new THREE.PlaneBufferGeometry(20, 20),
            helperMaterial.clone()
        );

        this.helper.rotation.set(0, Math.PI / 4, 0);
        this.game.world.scene.add(this.helper);

        this.edges = new THREE.Mesh(
            new THREE.BoxBufferGeometry(0.95, 0.95, 0.95),
            helperMaterial.clone()
        );

        this.game.world.scene.add(this.edges);

        this.onSolved = () => {};
        this.onMove = () => {};

        this.momentum = [];

        this.scramble = null;
        this.state = STILL;

        this.initDraggable();
    }

    enable() {
        this.draggable.enable();
    }

    disable() {
        this.draggable.disable();
    }

    initDraggable() {
        this.draggable = new Draggable(this.game.dom.game);

        this.draggable.onDragStart = (position) => {
            if (this.scramble !== null) return;
            if (this.state === PREPARING || this.state === ROTATING) return;

            this.gettingDrag = this.state === ANIMATING;

            const edgeIntersect = this.getIntersect(
                position.current,
                this.edges,
                false
            );

            if (edgeIntersect !== false) {
                this.dragNormal = edgeIntersect.face.normal.round();
                this.flipType = "layer";

                this.attach(this.helper, this.edges);

                this.helper.rotation.set(0, 0, 0);
                this.helper.position.set(0, 0, 0);
                this.helper.lookAt(this.dragNormal);
                this.helper.translateZ(0.5);
                this.helper.updateMatrixWorld();

                this.detach(this.helper, this.edges);
            } else {
                this.dragNormal = new THREE.Vector3(0, 0, 1);
                this.flipType = "cube";

                this.helper.position.set(0, 0, 0);
                this.helper.rotation.set(0, Math.PI / 4, 0);
                this.helper.updateMatrixWorld();
            }

            const planeIntersect = this.getIntersect(
                position.current,
                this.helper,
                false
            ).point;
            if (planeIntersect === false) return;

            this.dragCurrent = this.helper.worldToLocal(planeIntersect);
            this.dragTotal = new THREE.Vector3();
            this.state = this.state === STILL ? PREPARING : this.state;
        };

        this.draggable.onDragMove = (position) => {
            if (this.scramble !== null) return;
            if (
                this.state === STILL ||
                (this.state === ANIMATING && this.gettingDrag === false)
            )
                return;

            const planeIntersect = this.getIntersect(
                position.current,
                this.helper,
                false
            );
            if (planeIntersect === false) return;

            const point = this.helper.worldToLocal(planeIntersect.point.clone());

            this.dragDelta = point.clone().sub(this.dragCurrent).setZ(0);
            this.dragTotal.add(this.dragDelta);
            this.dragCurrent = point;
            this.addMomentumPoint(this.dragDelta);

            if (this.state === PREPARING && this.dragTotal.length() > 0.05) {
                this.dragDirection = this.getMainAxis(this.dragTotal);

                if (this.flipType === "layer") {
                    const direction = new THREE.Vector3();
                    direction[this.dragDirection] = 1;

                    const worldDirection = this.helper
                        .localToWorld(direction)
                        .sub(this.helper.position);
                    const objectDirection = this.edges
                        .worldToLocal(worldDirection)
                        .round();

                    this.flipAxis = objectDirection.cross(this.dragNormal).negate();

                    this.dragIntersect = this.getIntersect(
                        position.current,
                        this.game.cube.cubes,
                        true
                    );

                    this.selectLayer(this.getLayer(false));
                } else {
                    const axis =
                        this.dragDirection != "x" ?
                        this.dragDirection == "y" &&
                        position.current.x > this.game.world.width / 2 ?
                        "z" :
                        "x" :
                        "y";

                    this.flipAxis = new THREE.Vector3();
                    this.flipAxis[axis] = 1 * (axis == "x" ? -1 : 1);
                }

                this.flipAngle = 0;
                this.state = ROTATING;
            } else if (this.state === ROTATING) {
                const rotation = this.dragDelta[this.dragDirection];

                if (this.flipType === "layer") {
                    this.group.rotateOnAxis(this.flipAxis, rotation);
                    this.flipAngle += rotation;
                } else {
                    this.edges.rotateOnWorldAxis(this.flipAxis, rotation);
                    this.game.cube.object.rotation.copy(this.edges.rotation);
                    this.flipAngle += rotation;
                }
            }
        };

        this.draggable.onDragEnd = (position) => {
            if (this.scramble !== null) return;
            if (this.state !== ROTATING) {
                this.gettingDrag = false;
                this.state = STILL;
                return;
            }

            this.state = ANIMATING;

            const momentum = this.getMomentum()[this.dragDirection];
            const flip =
                Math.abs(momentum) > 0.05 && Math.abs(this.flipAngle) < Math.PI / 2;

            const angle = flip ?
                this.roundAngle(
                    this.flipAngle + Math.sign(this.flipAngle) * (Math.PI / 4)
                ) :
                this.roundAngle(this.flipAngle);

            const delta = angle - this.flipAngle;

            if (this.flipType === "layer") {
                this.rotateLayer(delta, false, (layer) => {
                    this.state = this.gettingDrag ? PREPARING : STILL;
                    this.gettingDrag = false;

                    this.checkIsSolved();
                });
            } else {
                this.rotateCube(delta, () => {
                    this.state = this.gettingDrag ? PREPARING : STILL;
                    this.gettingDrag = false;
                });
            }
        };
    }

    rotateLayer(rotation, scramble, callback) {
        const config = scramble ? 0 : this.flipConfig;

        const easing = this.flipEasings[config];
        const duration = this.flipSpeeds[config];
        const bounce = config == 2 ? this.bounceCube() : () => {};

        this.rotationTween = new Tween({
            easing: easing,
            duration: duration,
            onUpdate: (tween) => {
                let deltaAngle = tween.delta * rotation;
                this.group.rotateOnAxis(this.flipAxis, deltaAngle);
                bounce(tween.value, deltaAngle, rotation);
            },
            onComplete: () => {
                if (!scramble) this.onMove();

                const layer = this.flipLayer.slice(0);

                this.game.cube.object.rotation.setFromVector3(
                    this.snapRotation(this.game.cube.object.rotation.toVector3())
                );
                this.group.rotation.setFromVector3(
                    this.snapRotation(this.group.rotation.toVector3())
                );
                this.deselectLayer(this.flipLayer);

                callback(layer);
            },
        });
    }

    bounceCube() {
        let fixDelta = true;

        return (progress, delta, rotation) => {
            if (progress >= 1) {
                if (fixDelta) {
                    delta = (progress - 1) * rotation;
                    fixDelta = false;
                }

                this.game.cube.object.rotateOnAxis(this.flipAxis, delta);
            }
        };
    }

    rotateCube(rotation, callback) {
        const config = this.flipConfig;
        const easing = [Easing.Power.Out(4), Easing.Sine.Out(), Easing.Back.Out(2)][
            config
        ];
        const duration = [100, 150, 350][config];

        this.rotationTween = new Tween({
            easing: easing,
            duration: duration,
            onUpdate: (tween) => {
                this.edges.rotateOnWorldAxis(this.flipAxis, tween.delta * rotation);
                this.game.cube.object.rotation.copy(this.edges.rotation);
            },
            onComplete: () => {
                this.edges.rotation.setFromVector3(
                    this.snapRotation(this.edges.rotation.toVector3())
                );
                this.game.cube.object.rotation.copy(this.edges.rotation);
                callback();
            },
        });
    }

    selectLayer(layer) {
        this.group.rotation.set(0, 0, 0);
        this.movePieces(layer, this.game.cube.object, this.group);
        this.flipLayer = layer;
    }

    deselectLayer(layer) {
        this.movePieces(layer, this.group, this.game.cube.object);
        this.flipLayer = null;
    }

    movePieces(layer, from, to) {
        from.updateMatrixWorld();
        to.updateMatrixWorld();

        layer.forEach((index) => {
            const piece = this.game.cube.pieces[index];

            piece.applyMatrix(from.matrixWorld);
            from.remove(piece);
            piece.applyMatrix(new THREE.Matrix4().getInverse(to.matrixWorld));
            to.add(piece);
        });
    }

    getLayer(position) {
        const layer = [];
        let axis;

        if (position === false) {
            axis = this.getMainAxis(this.flipAxis);
            position = this.getPiecePosition(this.dragIntersect.object);
        } else {
            axis = this.getMainAxis(position);
        }

        this.game.cube.pieces.forEach((piece) => {
            const piecePosition = this.getPiecePosition(piece);

            if (piecePosition[axis] == position[axis]) layer.push(piece.name);
        });

        return layer;
    }

    getPiecePosition(piece) {
        let position = new THREE.Vector3()
            .setFromMatrixPosition(piece.matrixWorld)
            .multiplyScalar(3);

        return this.game.cube.object
            .worldToLocal(position.sub(this.game.cube.animator.position))
            .round();
    }

    scrambleCube() {
        if (this.scramble == null) {
            this.scramble = this.game.scrambler;
            this.scramble.callback =
                typeof callback !== "function" ? () => {} : callback;
        }

        const converted = this.scramble.converted;
        const move = converted[0];
        const layer = this.getLayer(move.position);

        this.flipAxis = new THREE.Vector3();
        this.flipAxis[move.axis] = 1;

        this.selectLayer(layer);
        this.rotateLayer(move.angle, true, () => {
            converted.shift();

            if (converted.length > 0) {
                this.scrambleCube();
            } else {
                this.scramble = null;
            }
        });
    }

    getIntersect(position, object, multiple) {
        this.raycaster.setFromCamera(
            this.draggable.convertPosition(position.clone()),
            this.game.world.camera
        );

        const intersect = multiple ?
            this.raycaster.intersectObjects(object) :
            this.raycaster.intersectObject(object);

        return intersect.length > 0 ? intersect[0] : false;
    }

    getMainAxis(vector) {
        return Object.keys(vector).reduce((a, b) =>
            Math.abs(vector[a]) > Math.abs(vector[b]) ? a : b
        );
    }

    detach(child, parent) {
        child.applyMatrix(parent.matrixWorld);
        parent.remove(child);
        this.game.world.scene.add(child);
    }

    attach(child, parent) {
        child.applyMatrix(new THREE.Matrix4().getInverse(parent.matrixWorld));
        this.game.world.scene.remove(child);
        parent.add(child);
    }

    addMomentumPoint(delta) {
        const time = Date.now();

        this.momentum = this.momentum.filter((moment) => time - moment.time < 500);

        if (delta !== false) this.momentum.push({ delta, time });
    }

    getMomentum() {
        const points = this.momentum.length;
        const momentum = new THREE.Vector2();

        this.addMomentumPoint(false);

        this.momentum.forEach((point, index) => {
            momentum.add(point.delta.multiplyScalar(index / points));
        });

        return momentum;
    }

    roundAngle(angle) {
        const round = Math.PI / 2;
        return Math.sign(angle) * Math.round(Math.abs(angle) / round) * round;
    }

    snapRotation(angle) {
        return angle.set(
            this.roundAngle(angle.x),
            this.roundAngle(angle.y),
            this.roundAngle(angle.z)
        );
    }

    checkIsSolved() {
        const start = performance.now();

        let solved = true;
        const sides = {
            "x-": [],
            "x+": [],
            "y-": [],
            "y+": [],
            "z-": [],
            "z+": [],
        };

        this.game.cube.edges.forEach((edge) => {
            const position = edge.parent
                .localToWorld(edge.position.clone())
                .sub(this.game.cube.object.position);

            const mainAxis = this.getMainAxis(position);
            const mainSign =
                position.multiplyScalar(2).round()[mainAxis] < 1 ? "-" : "+";

            sides[mainAxis + mainSign].push(edge.name);
        });

        Object.keys(sides).forEach((side) => {
            if (!sides[side].every((value) => value === sides[side][0]))
                solved = false;
        });

        if (solved) this.onSolved();
    }
}

class Scrambler {
    constructor(game) {
        this.game = game;

        this.scrambleLength = 20;

        this.moves = [];
        this.conveted = [];
        this.pring = "";
    }

    scramble(scramble) {
        let count = 0;
        this.moves = typeof scramble !== "undefined" ? scramble.split(" ") : [];

        if (this.moves.length < 1) {
            const faces = "UDLRFB";
            const modifiers = ["", "'", "2"];
            const total =
                typeof scramble === "undefined" ? this.scrambleLength : scramble;

            while (count < total) {
                const move =
                    faces[Math.floor(Math.random() * 6)] +
                    modifiers[Math.floor(Math.random() * 3)];
                if (count > 0 && move.charAt(0) == this.moves[count - 1].charAt(0))
                    continue;
                if (count > 1 && move.charAt(0) == this.moves[count - 2].charAt(0))
                    continue;
                this.moves.push(move);
                count++;
            }
        }

        this.callback = () => {};
        this.convert();
        this.print = this.moves.join(" ");

        return this;
    }

    convert(moves) {
        this.converted = [];

        this.moves.forEach((move) => {
            const face = move.charAt(0);
            const modifier = move.charAt(1);

            const axis = { D: "y", U: "y", L: "x", R: "x", F: "z", B: "z" }[face];
            const row = { D: -1, U: 1, L: -1, R: 1, F: 1, B: -1 }[face];

            const position = new THREE.Vector3();
            position[{ D: "y", U: "y", L: "x", R: "x", F: "z", B: "z" }[face]] = row;

            const angle = (Math.PI / 2) * -row * (modifier == "'" ? -1 : 1);

            const convertedMove = { position, axis, angle, name: move };

            this.converted.push(convertedMove);
            if (modifier == "2") this.converted.push(convertedMove);
        });
    }
}

class Transition {
    constructor(game) {
        this.game = game;

        this.tweens = {};
        this.durations = {};
        this.data = {
            cubeY: -0.2,
            cameraZoom: 0.85,
        };

        this.activeTransitions = 0;
    }

    init() {
        this.game.controls.disable();

        this.game.cube.object.position.y = this.data.cubeY;
        this.game.controls.edges.position.y = this.data.cubeY;
        this.game.cube.animator.position.y = 4;
        this.game.cube.animator.rotation.x = -Math.PI / 3;
        this.game.world.camera.zoom = this.data.cameraZoom;
        this.game.world.camera.updateProjectionMatrix();

        this.tweens.buttons = {};
        this.tweens.timer = [];
        this.tweens.title = [];
        this.tweens.best = [];
        this.tweens.complete = [];
        this.tweens.range = [];
        this.tweens.stats = [];
    }

    buttons(show, hide) {
        const buttonTween = (button, show) => {
            return new Tween({
                target: button.style,
                duration: 300,
                easing: show ? Easing.Power.Out(2) : Easing.Power.In(3),
                from: { opacity: show ? 0 : 1 },
                to: { opacity: show ? 1 : 0 },
                onUpdate: (tween) => {
                    const translate = show ? 1 - tween.value : tween.value;
                    button.style.transform = `translate3d(0, ${translate * 1.5}em, 0)`;
                },
                onComplete: () => (button.style.pointerEvents = show ? "all" : "none"),
            });
        };

        hide.forEach(
            (button) =>
            (this.tweens.buttons[button] = buttonTween(
                this.game.dom.buttons[button],
                false
            ))
        );

        setTimeout(
            () =>
            show.forEach((button) => {
                this.tweens.buttons[button] = buttonTween(
                    this.game.dom.buttons[button],
                    true
                );
            }),
            hide ? 500 : 0
        );
    }

    cube(show) {
        this.activeTransitions++;

        try {
            this.tweens.cube.stop();
        } catch (e) {}
        const currentY = this.game.cube.animator.position.y;
        const currentRotation = this.game.cube.animator.rotation.x;

        this.tweens.cube = new Tween({
            duration: show ? 3000 : 1250,
            easing: show ? Easing.Elastic.Out(0.8, 0.6) : Easing.Back.In(1),
            onUpdate: (tween) => {
                this.game.cube.animator.position.y = show ?
                    (1 - tween.value) * 4 :
                    currentY + tween.value * 4;

                this.game.cube.animator.rotation.x = show ?
                    ((1 - tween.value) * Math.PI) / 3 :
                    currentRotation + (tween.value * -Math.PI) / 3;
            },
        });

        this.durations.cube = show ? 1500 : 1500;

        setTimeout(() => this.activeTransitions--, this.durations.cube);
    }

    float() {
        try {
            this.tweens.float.stop();
        } catch (e) {}
        this.tweens.float = new Tween({
            duration: 1500,
            easing: Easing.Sine.InOut(),
            yoyo: true,
            onUpdate: (tween) => {
                this.game.cube.holder.position.y = -0.02 + tween.value * 0.04;
                this.game.cube.holder.rotation.x = 0.005 - tween.value * 0.01;
                this.game.cube.holder.rotation.z = -this.game.cube.holder.rotation.x;
                this.game.cube.holder.rotation.y = this.game.cube.holder.rotation.x;
            },
        });
    }

    zoom(play, time) {
        this.activeTransitions++;

        const zoom = play ? 1 : this.data.cameraZoom;
        const duration = time > 0 ? Math.max(time, 1500) : 1500;
        const rotations = time > 0 ? Math.round(duration / 1500) : 1;
        const easing = Easing.Power.InOut(time > 0 ? 2 : 3);

        this.tweens.zoom = new Tween({
            target: this.game.world.camera,
            duration: duration,
            easing: easing,
            to: { zoom: zoom },
            onUpdate: () => {
                this.game.world.camera.updateProjectionMatrix();
            },
        });

        this.tweens.rotate = new Tween({
            target: this.game.cube.animator.rotation,
            duration: duration,
            easing: easing,
            to: { y: -Math.PI * 2 * rotations },
            onComplete: () => {
                this.game.cube.animator.rotation.y = 0;
            },
        });

        this.durations.zoom = duration;

        setTimeout(() => this.activeTransitions--, this.durations.zoom);
    }

    elevate(complete) {
        this.activeTransitions++;

        const cubeY = (this.tweens.elevate = new Tween({
            target: this.game.cube.object.position,
            duration: complete ? 1500 : 0,
            easing: Easing.Power.InOut(3),
            to: { y: complete ? -0.05 : this.data.cubeY },
        }));

        this.durations.elevate = 1500;

        setTimeout(() => this.activeTransitions--, this.durations.elevate);
    }

    complete(show, best) {
        this.activeTransitions++;

        const text = best ? this.game.dom.texts.best : this.game.dom.texts.complete;

        if (text.querySelector("span i") === null)
            text.querySelectorAll("span").forEach((span) => this.splitLetters(span));

        const letters = text.querySelectorAll(".icon, i");

        this.flipLetters(best ? "best" : "complete", letters, show);

        text.style.opacity = 1;

        const duration = this.durations[best ? "best" : "complete"];

        if (!show)
            setTimeout(
                () => (this.game.dom.texts.timer.style.transform = ""),
                duration
            );

        setTimeout(() => this.activeTransitions--, duration);
    }

    stats(show) {
        if (show) this.game.scores.calcStats();

        this.activeTransitions++;

        this.tweens.stats.forEach((tween) => {
            tween.stop();
            tween = null;
        });

        let tweenId = -1;

        const stats = this.game.dom.stats.querySelectorAll(".stats");
        const easing = show ? Easing.Power.Out(2) : Easing.Power.In(3);

        stats.forEach((stat, index) => {
            const delay = index * (show ? 80 : 60);

            this.tweens.stats[tweenId++] = new Tween({
                delay: delay,
                duration: 400,
                easing: easing,
                onUpdate: (tween) => {
                    const translate = show ? (1 - tween.value) * 2 : tween.value;
                    const opacity = show ? tween.value : 1 - tween.value;

                    stat.style.transform = `translate3d(0, ${translate}em, 0)`;
                    stat.style.opacity = opacity;
                },
            });
        });

        this.durations.stats = 0;

        setTimeout(() => this.activeTransitions--, this.durations.stats);
    }

    preferences(show) {
        this.activeTransitions++;

        this.tweens.range.forEach((tween) => {
            tween.stop();
            tween = null;
        });

        let tweenId = -1;
        let listMax = 0;

        const ranges = this.game.dom.prefs.querySelectorAll(".range");
        const easing = show ? Easing.Power.Out(2) : Easing.Power.In(3);

        ranges.forEach((range, rangeIndex) => {
            const label = range.querySelector(".range__label");
            const track = range.querySelector(".range__track-line");
            const handle = range.querySelector(".range__handle");
            const list = range.querySelectorAll(".range__list div");

            const delay = rangeIndex * (show ? 120 : 100);

            label.style.opacity = show ? 0 : 1;
            track.style.opacity = show ? 0 : 1;
            handle.style.opacity = show ? 0 : 1;
            handle.style.pointerEvents = show ? "all" : "none";

            this.tweens.range[tweenId++] = new Tween({
                delay: show ? delay : delay,
                duration: 400,
                easing: easing,
                onUpdate: (tween) => {
                    const translate = show ? 1 - tween.value : tween.value;
                    const opacity = show ? tween.value : 1 - tween.value;

                    label.style.transform = `translate3d(0, ${translate}em, 0)`;
                    label.style.opacity = opacity;
                },
            });

            this.tweens.range[tweenId++] = new Tween({
                delay: show ? delay + 100 : delay,
                duration: 400,
                easing: easing,
                onUpdate: (tween) => {
                    const translate = show ? 1 - tween.value : tween.value;
                    const scale = show ? tween.value : 1 - tween.value;
                    const opacity = scale;

                    track.style.transform = `translate3d(0, ${translate}em, 0) scale3d(${scale}, 1, 1)`;
                    track.style.opacity = opacity;
                },
            });

            this.tweens.range[tweenId++] = new Tween({
                delay: show ? delay + 100 : delay,
                duration: 400,
                easing: easing,
                onUpdate: (tween) => {
                    const translate = show ? 1 - tween.value : tween.value;
                    const opacity = 1 - translate;
                    const scale = 0.5 + opacity * 0.5;

                    handle.style.transform = `translate3d(0, ${translate}em, 0) scale3d(${scale}, ${scale}, ${scale})`;
                    handle.style.opacity = opacity;
                },
            });

            list.forEach((listItem, labelIndex) => {
                listItem.style.opacity = show ? 0 : 1;

                this.tweens.range[tweenId++] = new Tween({
                    delay: show ? delay + 200 + labelIndex * 50 : delay,
                    duration: 400,
                    easing: easing,
                    onUpdate: (tween) => {
                        const translate = show ? 1 - tween.value : tween.value;
                        const opacity = show ? tween.value : 1 - tween.value;

                        listItem.style.transform = `translate3d(0, ${translate}em, 0)`;
                        listItem.style.opacity = opacity;
                    },
                });
            });

            listMax = list.length > listMax ? list.length - 1 : listMax;

            range.style.opacity = 1;
        });

        this.durations.preferences = show ?
            (ranges.length - 1) * 100 + 200 + listMax * 50 + 400 :
            (ranges.length - 1) * 100 + 400;

        setTimeout(() => this.activeTransitions--, this.durations.preferences);
    }

    title(show) {
        this.activeTransitions++;

        const title = this.game.dom.texts.title;

        if (title.querySelector("span i") === null)
            title.querySelectorAll("span").forEach((span) => this.splitLetters(span));

        const letters = title.querySelectorAll("i");

        this.flipLetters("title", letters, show);

        title.style.opacity = 1;

        const note = this.game.dom.texts.note;

        this.tweens.title[letters.length] = new Tween({
            target: note.style,
            easing: Easing.Sine.InOut(),
            duration: show ? 800 : 400,
            yoyo: show ? true : null,
            from: { opacity: show ? 0 : parseFloat(getComputedStyle(note).opacity) },
            to: { opacity: show ? 1 : 0 },
        });

        setTimeout(() => this.activeTransitions--, this.durations.title);
    }

    timer(show) {
        this.activeTransitions++;

        const timer = this.game.dom.texts.timer;

        timer.style.opacity = 0;
        this.game.timer.convert();
        this.game.timer.setText();

        this.splitLetters(timer);
        const letters = timer.querySelectorAll("i");
        this.flipLetters("timer", letters, show);

        timer.style.opacity = 1;

        setTimeout(() => this.activeTransitions--, this.durations.timer);
    }

    splitLetters(element) {
        const text = element.innerHTML;

        element.innerHTML = "";

        text.split("").forEach((letter) => {
            const i = document.createElement("i");

            i.innerHTML = letter;

            element.appendChild(i);
        });
    }

    flipLetters(type, letters, show) {
        try {
            this.tweens[type].forEach((tween) => tween.stop());
        } catch (e) {}
        letters.forEach((letter, index) => {
            letter.style.opacity = show ? 0 : 1;

            this.tweens[type][index] = new Tween({
                easing: Easing.Sine.Out(),
                duration: show ? 800 : 400,
                delay: index * 50,
                onUpdate: (tween) => {
                    const rotation = show ? (1 - tween.value) * -80 : tween.value * 80;

                    letter.style.transform = `rotate3d(0, 1, 0, ${rotation}deg)`;
                    letter.style.opacity = show ? tween.value : 1 - tween.value;
                },
            });
        });

        this.durations[type] = (letters.length - 1) * 50 + (show ? 800 : 400);
    }
}

class Timer extends Animation {
    constructor(game) {
        super(false);

        this.game = game;
        this.reset();
    }

    start(continueGame) {
        this.startTime = continueGame ? Date.now() - this.deltaTime : Date.now();
        this.deltaTime = 0;
        this.converted = this.convert();

        super.start();
    }

    reset() {
        this.startTime = 0;
        this.currentTime = 0;
        this.deltaTime = 0;
        this.converted = "0:00";
    }

    stop() {
        this.currentTime = Date.now();
        this.deltaTime = this.currentTime - this.startTime;
        this.convert();

        super.stop();

        return { time: this.converted, millis: this.deltaTime };
    }

    update() {
        const old = this.converted;

        this.currentTime = Date.now();
        this.deltaTime = this.currentTime - this.startTime;
        this.convert();

        if (this.converted != old) {
            localStorage.setItem("theCube_time", this.deltaTime);
            this.setText();
        }
    }

    convert() {
        const seconds = parseInt((this.deltaTime / 1000) % 60);
        const minutes = parseInt(this.deltaTime / (1000 * 60));

        this.converted = minutes + ":" + (seconds < 10 ? "0" : "") + seconds;
    }

    setText() {
        this.game.dom.texts.timer.innerHTML = this.converted;
    }
}

const RangeHTML = [
    '<div class="range">',
    '<div class="range__label"></div>',
    '<div class="range__track">',
    '<div class="range__track-line"></div>',
    '<div class="range__handle"><div></div></div>',
    "</div>",
    '<div class="range__list"></div>',
    "</div>",
].join("\n");

document.querySelectorAll("range").forEach((el) => {
    const temp = document.createElement("div");
    temp.innerHTML = RangeHTML;

    const range = temp.querySelector(".range");
    const rangeLabel = range.querySelector(".range__label");
    const rangeList = range.querySelector(".range__list");

    range.setAttribute("name", el.getAttribute("name"));
    rangeLabel.innerHTML = el.getAttribute("title");

    el.getAttribute("list")
        .split(",")
        .forEach((listItemText) => {
            const listItem = document.createElement("div");
            listItem.innerHTML = listItemText;
            rangeList.appendChild(listItem);
        });

    el.parentNode.replaceChild(range, el);
});

class Range {
    constructor(name, options) {
        options = Object.assign({
                range: [0, 1],
                value: 0,
                step: 0,
                onUpdate: () => {},
                onComplete: () => {},
            },
            options || {}
        );

        this.element = document.querySelector('.range[name="' + name + '"]');
        this.track = this.element.querySelector(".range__track");
        this.handle = this.element.querySelector(".range__handle");

        this.value = options.value;
        this.min = options.range[0];
        this.max = options.range[1];
        this.step = options.step;

        this.onUpdate = options.onUpdate;
        this.onComplete = options.onComplete;

        this.value = this.round(this.limitValue(this.value));
        this.setHandlePosition();

        this.initDraggable();
    }

    initDraggable() {
        let current;

        this.draggable = new Draggable(this.handle, { calcDelta: true });

        this.draggable.onDragStart = (position) => {
            current = this.positionFromValue(this.value);
            this.handle.style.left = current + "px";
        };

        this.draggable.onDragMove = (position) => {
            current = this.limitPosition(current + position.delta.x);
            this.value = this.round(this.valueFromPosition(current));
            this.setHandlePosition();

            this.onUpdate(this.value);
        };

        this.draggable.onDragEnd = (position) => {
            this.onComplete(this.value);
        };
    }

    round(value) {
        if (this.step < 1) return value;

        return Math.round((value - this.min) / this.step) * this.step + this.min;
    }

    limitValue(value) {
        const max = Math.max(this.max, this.min);
        const min = Math.min(this.max, this.min);

        return Math.min(Math.max(value, min), max);
    }

    limitPosition(position) {
        return Math.min(Math.max(position, 0), this.track.offsetWidth);
    }

    percentsFromValue(value) {
        return (value - this.min) / (this.max - this.min);
    }

    valueFromPosition(position) {
        return (
            this.min + (this.max - this.min) * (position / this.track.offsetWidth)
        );
    }

    positionFromValue(value) {
        return this.percentsFromValue(value) * this.track.offsetWidth;
    }

    setHandlePosition() {
        this.handle.style.left = this.percentsFromValue(this.value) * 100 + "%";
    }
}

class Preferences {
    constructor(game) {
        this.game = game;
    }

    init() {
        this.ranges = {
            flip: new Range("flip", {
                value: this.game.controls.flipConfig,
                range: [0, 2],
                step: 1,
                onUpdate: (value) => {
                    this.game.controls.flipConfig = value;
                },
            }),

            scramble: new Range("scramble", {
                value: this.game.scrambler.scrambleLength,
                range: [20, 30],
                step: 5,
                onUpdate: (value) => {
                    this.game.scrambler.scrambleLength = value;
                },
            }),

            fov: new Range("fov", {
                value: this.game.world.fov,
                range: [2, 45],
                onUpdate: (value) => {
                    this.game.world.fov = value;
                    this.game.world.resize();
                },
            }),

            theme: new Range("theme", {
                value: { cube: 0, erno: 1, dust: 2, camo: 3, rain: 4 }[
                    this.game.themes.theme
                ],
                range: [0, 4],
                step: 1,
                onUpdate: (value) => {
                    const theme = ["cube", "erno", "dust", "camo", "rain"][value];
                    this.game.themes.setTheme(theme);
                },
            }),
        };
    }
}

class Confetti {
    constructor(game) {
        this.game = game;
        this.started = 0;

        this.options = {
            speed: { min: 0.0011, max: 0.0022 },
            revolution: { min: 0.01, max: 0.05 },
            size: { min: 0.1, max: 0.15 },
            colors: [0x41aac8, 0x82ca38, 0xffef48, 0xef3923, 0xff8c0a],
        };

        this.geometry = new THREE.PlaneGeometry(1, 1);
        this.material = new THREE.MeshLambertMaterial({ side: THREE.DoubleSide });

        this.holders = [
            new ConfettiStage(this.game, this, 1, 20),
            new ConfettiStage(this.game, this, -1, 30),
        ];
    }

    start() {
        if (this.started > 0) return;

        this.holders.forEach((holder) => {
            this.game.world.scene.add(holder.holder);
            holder.start();
            this.started++;
        });
    }

    stop() {
        if (this.started == 0) return;

        this.holders.forEach((holder) => {
            holder.stop(() => {
                this.game.world.scene.remove(holder.holder);
                this.started--;
            });
        });
    }

    updateColors(colors) {
        this.holders.forEach((holder) => {
            holder.options.colors.forEach((color, index) => {
                holder.options.colors[index] = colors[["D", "F", "R", "B", "L"][index]];
            });
        });
    }
}

class ConfettiStage extends Animation {
    constructor(game, parent, distance, count) {
        super(false);

        this.game = game;
        this.parent = parent;

        this.distanceFromCube = distance;

        this.count = count;
        this.particles = [];

        this.holder = new THREE.Object3D();
        this.holder.rotation.copy(this.game.world.camera.rotation);

        this.object = new THREE.Object3D();
        this.holder.add(this.object);

        this.resizeViewport = this.resizeViewport.bind(this);
        this.game.world.onResize.push(this.resizeViewport);
        this.resizeViewport();

        this.geometry = this.parent.geometry;
        this.material = this.parent.material;

        this.options = this.parent.options;

        let i = this.count;
        while (i--) this.particles.push(new Particle(this));
    }

    start() {
        this.time = performance.now();
        this.playing = true;

        let i = this.count;
        while (i--) this.particles[i].reset();

        super.start();
    }

    stop(callback) {
        this.playing = false;
        this.completed = 0;
        this.callback = callback;
    }

    reset() {
        super.stop();

        this.callback();
    }

    update() {
        const now = performance.now();
        const delta = now - this.time;
        this.time = now;

        let i = this.count;

        while (i--)
            if (!this.particles[i].completed) this.particles[i].update(delta);

        if (!this.playing && this.completed == this.count) this.reset();
    }

    resizeViewport() {
        const fovRad = this.game.world.camera.fov * THREE.Math.DEG2RAD;

        this.height =
            2 *
            Math.tan(fovRad / 2) *
            (this.game.world.camera.position.length() - this.distanceFromCube);
        this.width = this.height * this.game.world.camera.aspect;

        const scale = 1 / this.game.transition.data.cameraZoom;

        this.width *= scale;
        this.height *= scale;

        this.object.position.z = this.distanceFromCube;
        this.object.position.y = this.height / 2;
    }
}

class Particle {
    constructor(confetti) {
        this.confetti = confetti;
        this.options = this.confetti.options;

        this.velocity = new THREE.Vector3();
        this.force = new THREE.Vector3();

        this.mesh = new THREE.Mesh(
            this.confetti.geometry,
            this.confetti.material.clone()
        );
        this.confetti.object.add(this.mesh);

        this.size = THREE.Math.randFloat(
            this.options.size.min,
            this.options.size.max
        );
        this.mesh.scale.set(this.size, this.size, this.size);

        return this;
    }

    reset(randomHeight = true) {
        this.completed = false;

        this.color = new THREE.Color(
            this.options.colors[
                Math.floor(Math.random() * this.options.colors.length)
            ]
        );
        this.mesh.material.color.set(this.color);

        this.speed =
            THREE.Math.randFloat(this.options.speed.min, this.options.speed.max) * -1;
        this.mesh.position.x = THREE.Math.randFloat(-this.confetti.width / 2,
            this.confetti.width / 2
        );
        this.mesh.position.y = randomHeight ?
            THREE.Math.randFloat(this.size, this.confetti.height + this.size) :
            this.size;

        this.revolutionSpeed = THREE.Math.randFloat(
            this.options.revolution.min,
            this.options.revolution.max
        );
        this.revolutionAxis = ["x", "y", "z"][Math.floor(Math.random() * 3)];
        this.mesh.rotation.set(
            (Math.random() * Math.PI) / 3,
            (Math.random() * Math.PI) / 3,
            (Math.random() * Math.PI) / 3
        );
    }

    stop() {
        this.completed = true;
        this.confetti.completed++;
    }

    update(delta) {
        this.mesh.position.y += this.speed * delta;
        this.mesh.rotation[this.revolutionAxis] += this.revolutionSpeed;

        if (this.mesh.position.y < -this.confetti.height - this.size)
            this.confetti.playing ? this.reset(false) : this.stop();
    }
}

class Scores {
    constructor(game) {
        this.game = game;

        this.scores = [];
        this.solves = 0;
        this.best = 0;
        this.worst = 0;
    }

    addScore(time) {
        this.scores.push(time);
        this.solves++;

        if (this.scores.lenght > 100) this.scores.shift();

        let bestTime = false;

        if (time < this.best || this.best === 0) {
            this.best = time;
            bestTime = true;
        }

        if (time > this.worst) this.worst = time;

        return bestTime;
    }

    calcStats() {
        this.setStat("total-solves", this.solves);
        this.setStat("best-time", this.convertTime(this.best));
        this.setStat("worst-time", this.convertTime(this.worst));
        this.setStat("average-5", this.getAverage(5));
        this.setStat("average-12", this.getAverage(12));
        this.setStat("average-25", this.getAverage(25));
    }

    setStat(name, value) {
        if (value === 0) return;

        this.game.dom.stats.querySelector(`.stats[name="${name}"] b`).innerHTML =
            value;
    }

    getAverage(count) {
        if (this.scores.length < count) return 0;

        return this.convertTime(
            this.scores.slice(-count).reduce((a, b) => a + b, 0) / count
        );
    }

    convertTime(time) {
        if (time <= 0) return 0;

        const seconds = parseInt((time / 1000) % 60);
        const minutes = parseInt(time / (1000 * 60));

        return minutes + ":" + (seconds < 10 ? "0" : "") + seconds;
    }
}

class Storage {
    constructor(game) {
        this.game = game;
    }

    init() {
        this.loadGame();
        this.loadPreferences();
    }

    loadGame() {
        this.game.saved = false;
    }

    loadPreferences() {
        this.game.controls.flipConfig = 0;
        this.game.scrambler.scrambleLength = 20;

        this.game.world.fov = 10;
        this.game.world.resize();

        this.game.themes.setTheme("cube");

        return false;
    }
}

class Themes {
    constructor(game) {
        this.game = game;
        this.theme = null;

        this.colors = {
            cube: {
                U: 0xfff7ff,
                D: 0xffef48,
                F: 0xef3923,
                R: 0x41aac8,
                B: 0xff8c0a,
                L: 0x82ca38,
                P: 0x08101a,
                G: 0xd1d5db,
            },
            erno: {
                U: 0xffffff,
                D: 0xffd500,
                F: 0xc41e3a,
                R: 0x0051ba,
                B: 0xff5800,
                L: 0x009e60,
                P: 0x111111,
                G: 0x8abdff,
            },
            dust: {
                U: 0xfff6eb,
                D: 0xe7c48d,
                F: 0x8f253e,
                R: 0x607e69,
                B: 0xbe6f62,
                L: 0x849f5d,
                P: 0x111111,
                G: 0xe7c48d,
            },
            camo: {
                U: 0xfff6eb,
                D: 0xbfb672,
                F: 0x805831,
                R: 0x718456,
                B: 0x37241c,
                L: 0x37431d,
                P: 0x111111,
                G: 0xbfb672,
            },
            rain: {
                U: 0xfafaff,
                D: 0xedb92d,
                F: 0xce2135,
                R: 0x449a89,
                B: 0xec582f,
                L: 0xa3a947,
                P: 0x111111,
                G: 0x87b9ac,
            },
        };
    }

    setTheme(theme) {
        if (theme === this.theme) return;

        this.theme = theme;

        const colors = this.colors[this.theme];

        this.game.cube.pieces.forEach((piece) => {
            piece.userData.cube.material.color.setHex(colors.P);
        });

        this.game.cube.edges.forEach((edge) => {
            edge.material.color.setHex(colors[edge.name]);
        });

        this.game.dom.rangeHandles.forEach((handle) => {
            handle.style.background = "#" + colors.R.toString(16).padStart(6, "0");
        });

        this.game.confetti.updateColors(colors);

        this.game.dom.back.style.background =
            "#" + colors.G.toString(16).padStart(6, "0");
        this.game.dom.buttons.pwa.style.color =
            "#" + colors.R.toString(16).padStart(6, "0");
    }
}

class IconsConverter {
    constructor(options) {
        options = Object.assign({
                tagName: "icon",
                className: "icon",
                styles: false,
                icons: {},
                observe: false,
                convert: false,
            },
            options || {}
        );

        this.tagName = options.tagName;
        this.className = options.className;
        this.icons = options.icons;

        this.svgTag = document.createElementNS("http://www.w3.org/2000/svg", "svg");
        this.svgTag.setAttribute("class", this.className);

        if (options.styles) this.addStyles();
        if (options.convert) this.convertAllIcons();

        if (options.observe) {
            const MutationObserver =
                window.MutationObserver || window.WebKitMutationObserver;
            this.observer = new MutationObserver((mutations) => {
                this.convertAllIcons();
            });
            this.observer.observe(document.documentElement, {
                childList: true,
                subtree: true,
            });
        }

        return this;
    }

    convertAllIcons() {
        document.querySelectorAll(this.tagName).forEach((icon) => {
            this.convertIcon(icon);
        });
    }

    convertIcon(icon) {
        const svgData = this.icons[icon.attributes[0].localName];

        if (typeof svgData === "undefined") return;

        const svg = this.svgTag.cloneNode(true);
        const viewBox = svgData.viewbox.split(" ");

        svg.setAttributeNS(null, "viewBox", svgData.viewbox);
        svg.style.width = viewBox[2] / viewBox[3] + "em";
        svg.style.height = "1em";
        svg.innerHTML = svgData.content;

        icon.parentNode.replaceChild(svg, icon);
    }

    addStyles() {
        const style = document.createElement("style");
        style.innerHTML = `.${this.className} { display: inline-block; font-size: inherit; overflow: visible; vertical-align: -0.125em; preserveAspectRatio: none; }`;
        document.head.appendChild(style);
    }
}

const Icons = new IconsConverter({
    icons: {
        settings: {
            viewbox: "0 0 512 512",
            content: '<path fill="currentColor" d="M444.788 291.1l42.616 24.599c4.867 2.809 7.126 8.618 5.459 13.985-11.07 35.642-29.97 67.842-54.689 94.586a12.016 12.016 0 0 1-14.832 2.254l-42.584-24.595a191.577 191.577 0 0 1-60.759 35.13v49.182a12.01 12.01 0 0 1-9.377 11.718c-34.956 7.85-72.499 8.256-109.219.007-5.49-1.233-9.403-6.096-9.403-11.723v-49.184a191.555 191.555 0 0 1-60.759-35.13l-42.584 24.595a12.016 12.016 0 0 1-14.832-2.254c-24.718-26.744-43.619-58.944-54.689-94.586-1.667-5.366.592-11.175 5.459-13.985L67.212 291.1a193.48 193.48 0 0 1 0-70.199l-42.616-24.599c-4.867-2.809-7.126-8.618-5.459-13.985 11.07-35.642 29.97-67.842 54.689-94.586a12.016 12.016 0 0 1 14.832-2.254l42.584 24.595a191.577 191.577 0 0 1 60.759-35.13V25.759a12.01 12.01 0 0 1 9.377-11.718c34.956-7.85 72.499-8.256 109.219-.007 5.49 1.233 9.403 6.096 9.403 11.723v49.184a191.555 191.555 0 0 1 60.759 35.13l42.584-24.595a12.016 12.016 0 0 1 14.832 2.254c24.718 26.744 43.619 58.944 54.689 94.586 1.667 5.366-.592 11.175-5.459 13.985L444.788 220.9a193.485 193.485 0 0 1 0 70.2zM336 256c0-44.112-35.888-80-80-80s-80 35.888-80 80 35.888 80 80 80 80-35.888 80-80z" class=""></path>',
        },
        back: {
            viewbox: "0 0 512 512",
            content: '<path transform="translate(512, 0) scale(-1,1)" fill="currentColor" d="M503.691 189.836L327.687 37.851C312.281 24.546 288 35.347 288 56.015v80.053C127.371 137.907 0 170.1 0 322.326c0 61.441 39.581 122.309 83.333 154.132 13.653 9.931 33.111-2.533 28.077-18.631C66.066 312.814 132.917 274.316 288 272.085V360c0 20.7 24.3 31.453 39.687 18.164l176.004-152c11.071-9.562 11.086-26.753 0-36.328z" class=""></path>',
        },
        trophy: {
            viewbox: "0 0 576 512",
            content: '<path fill="currentColor" d="M552 64H448V24c0-13.3-10.7-24-24-24H152c-13.3 0-24 10.7-24 24v40H24C10.7 64 0 74.7 0 88v56c0 66.5 77.9 131.7 171.9 142.4C203.3 338.5 240 360 240 360v72h-48c-35.3 0-64 20.7-64 56v12c0 6.6 5.4 12 12 12h296c6.6 0 12-5.4 12-12v-12c0-35.3-28.7-56-64-56h-48v-72s36.7-21.5 68.1-73.6C498.4 275.6 576 210.3 576 144V88c0-13.3-10.7-24-24-24zM64 144v-16h64.2c1 32.6 5.8 61.2 12.8 86.2-47.5-16.4-77-49.9-77-70.2zm448 0c0 20.2-29.4 53.8-77 70.2 7-25 11.8-53.6 12.8-86.2H512v16zm-127.3 4.7l-39.6 38.6 9.4 54.6c1.7 9.8-8.7 17.2-17.4 12.6l-49-25.8-49 25.8c-8.8 4.6-19.1-2.9-17.4-12.6l9.4-54.6-39.6-38.6c-7.1-6.9-3.2-19 6.7-20.5l54.8-8 24.5-49.6c4.4-8.9 17.1-8.9 21.5 0l24.5 49.6 54.8 8c9.6 1.5 13.5 13.6 6.4 20.5z" class=""></path>',
        },
        share: {
            viewbox: "0 0 36 50",
            content: '<path fill="currentColor" d="M19,4.414L19,32C19,32.552 18.552,33 18,33C17.448,33 17,32.552 17,32L17,4.414L10.707,10.707C10.317,11.098 9.683,11.098 9.293,10.707C8.902,10.317 8.902,9.683 9.293,9.293L18,0.586L26.707,9.293C27.098,9.683 27.098,10.317 26.707,10.707C26.317,11.098 25.683,11.098 25.293,10.707L19,4.414ZM34,18L26,18C25.448,18 25,17.552 25,17C25,16.448 25.448,16 26,16L36,16L36,50L0,50L0,16L10,16C10.552,16 11,16.448 11,17C11,17.552 10.552,18 10,18L2,18L2,48L34,48L34,18Z" />',
        },
        pwa: {
            viewbox: "0 0 740 280",
            content: '<path d="M544.62 229.7L565.998 175.641H627.722L598.43 93.6366L635.066 0.988922L740 279.601H662.615L644.683 229.7H544.62V229.7Z" fill="#3d3d3d"/><path d="M478.6 279.601L590.935 0.989288H516.461L439.618 181.035L384.974 0.989655H327.73L269.058 181.035L227.681 98.9917L190.236 214.352L228.254 279.601H301.545L354.565 118.139L405.116 279.601H478.6V279.601Z" fill="currentColor"/><path d="M70.6927 183.958H116.565C130.46 183.958 142.834 182.407 153.685 179.305L165.548 142.757L198.704 40.6105C196.177 36.6063 193.293 32.8203 190.051 29.2531C173.028 10.4101 148.121 0.988861 115.33 0.988861H0V279.601H70.6927V183.958V183.958ZM131.411 65.0863C138.061 71.7785 141.385 80.7339 141.385 91.9534C141.385 103.259 138.461 112.225 132.614 118.853C126.203 126.217 114.399 129.898 97.2023 129.898H70.6927V55.0474H97.3972C113.424 55.0474 124.762 58.3937 131.411 65.0863V65.0863Z" fill="#3d3d3d"/>',
        },
    },

    convert: true,
});

const MENU = 0;
const PLAYING = 1;
const COMPLETE = 2;
const STATS = 3;
const PREFS = 4;

const SHOW = true;
const HIDE = false;

class Game {
    constructor() {
        const qs = document.querySelector.bind(document);
        const qsa = document.querySelectorAll.bind(document);

        this.dom = {
            ui: qs(".ui"),
            game: qs(".ui__game"),
            back: qs(".ui__background"),
            texts: qs(".ui__texts"),
            prefs: qs(".ui__prefs"),
            stats: qs(".ui__stats"),
            texts: {
                title: qs(".text--title"),
                note: qs(".text--note"),
                timer: qs(".text--timer"),
                stats: qs(".text--timer"),
                complete: qs(".text--complete"),
                best: qs(".text--best-time"),
            },
            buttons: {
                prefs: qs(".btn--prefs"),
                back: qs(".btn--back"),
                stats: qs(".btn--stats"),
                pwa: qs(".btn--pwa"),
            },
            rangeHandles: qsa(".range__handle div"),
        };

        this.world = new World(this);
        this.cube = new Cube(this);
        this.controls = new Controls(this);
        this.scrambler = new Scrambler(this);
        this.transition = new Transition(this);
        this.timer = new Timer(this);
        this.preferences = new Preferences(this);
        this.scores = new Scores(this);
        this.storage = new Storage(this);
        this.confetti = new Confetti(this);
        this.themes = new Themes(this);

        this.initActions();

        this.state = MENU;
        this.saved = false;
        this.newGame = false;

        this.storage.init();
        this.preferences.init();
        this.transition.init();

        this.scores.calcStats();

        setTimeout(() => {
            this.transition.float();
            this.transition.cube(SHOW);

            setTimeout(() => this.transition.title(SHOW), 700);
            setTimeout(() => this.transition.buttons(["prefs", "pwa"], []), 1000);
        }, 500);
    }

    initActions() {
        let tappedTwice = false;

        this.dom.game.onclick = (event) => {
            if (this.transition.activeTransitions > 0) return;
            if (this.state === PLAYING) return;

            if (this.state === MENU) {
                if (!tappedTwice) {
                    tappedTwice = true;
                    setTimeout(() => (tappedTwice = false), 300);
                    return false;
                }

                if (!this.saved) {
                    this.scrambler.scramble();
                    this.controls.scrambleCube();
                    this.newGame = true;
                }

                const duration = this.saved ?
                    0 :
                    this.scrambler.converted.length * this.controls.flipSpeeds[0];

                this.state = PLAYING;
                this.saved = true;

                this.transition.buttons([], ["pwa", "prefs"]);

                this.transition.zoom(PLAYING, duration);
                this.transition.title(HIDE);

                setTimeout(() => {
                    this.transition.timer(SHOW);
                    this.transition.buttons(["back"], []);
                }, this.transition.durations.zoom - 1000);

                setTimeout(() => {
                    this.controls.enable();
                    if (!this.newGame) this.timer.start(true);
                }, this.transition.durations.zoom);
            } else if (this.state === COMPLETE) {
                this.state = STATS;
                this.saved = false;

                this.transition.timer(HIDE);
                this.transition.complete(HIDE, this.bestTime);
                this.transition.cube(HIDE);
                this.timer.reset();

                setTimeout(() => {
                    this.cube.reset();
                    this.confetti.stop();

                    this.transition.stats(SHOW);
                    this.transition.elevate(0);
                }, 1000);

                return false;
            } else if (this.state === STATS) {
                this.state = MENU;

                this.transition.buttons(["pwa", "prefs"], []);

                this.transition.stats(HIDE);

                setTimeout(() => this.transition.cube(SHOW), 500);
                setTimeout(() => this.transition.title(SHOW), 1200);
            }
        };

        this.controls.onMove = () => {
            if (this.newGame) {
                this.timer.start(true);
                this.newGame = false;
            }
        };

        this.dom.buttons.back.onclick = (event) => {
            $("#set").removeClass("xs");
            $("#set").addClass("yc");
            console.log("yc");

            if (this.transition.activeTransitions > 0) return;

            if (this.state === PREFS) {
                this.state = MENU;

                this.transition.buttons(["pwa", "prefs"], ["back"]);

                this.transition.preferences(HIDE);

                setTimeout(() => this.transition.cube(SHOW), 500);
                setTimeout(() => this.transition.title(SHOW), 1200);
            } else if (this.state === PLAYING) {
                this.state = MENU;

                this.transition.buttons(["pwa", "prefs"], ["back"]);

                this.transition.zoom(MENU, 0);

                this.controls.disable();
                if (!this.newGame) this.timer.stop();
                this.transition.timer(HIDE);

                setTimeout(
                    () => this.transition.title(SHOW),
                    this.transition.durations.zoom - 1000
                );

                this.playing = false;
                this.controls.disable();
            }
        };

        this.dom.buttons.prefs.onclick = (event) => {
            $("#set").removeClass("yc");
            $("#set").addClass("xs");
            console.log("xs");
            if (this.transition.activeTransitions > 0) return;

            this.state = PREFS;

            this.transition.buttons(["back"], ["pwa", "prefs"]);

            this.transition.title(HIDE);
            this.transition.cube(HIDE);

            setTimeout(() => this.transition.preferences(SHOW), 1000);
        };

        this.dom.buttons.stats.onclick = (event) => {
            if (this.transition.activeTransitions > 0) return;

            this.state = STATS;

            this.transition.buttons([], ["pwa", "prefs"]);

            this.transition.title(HIDE);
            this.transition.cube(HIDE);

            setTimeout(() => this.transition.stats(SHOW), 1000);
        };

        this.controls.onSolved = () => {
            this.transition.buttons([], ["back"]);

            this.state = COMPLETE;
            this.saved = false;

            this.controls.disable();
            this.timer.stop();

            this.bestTime = this.scores.addScore(this.timer.deltaTime);

            this.transition.zoom(MENU, 0);
            this.transition.elevate(SHOW);

            setTimeout(() => {
                this.transition.complete(SHOW, this.bestTime);
                this.confetti.start();
            }, 1000);
        };
    }
}

window.game = new Game();

// var set = document.getElementById("set");
// var set = document.getElementById("set")
$(".ui__prefs").on("mouseover", function() {
    // alert("sss");
});
$(".ui__prefs").on("mouseout", function() {
    // alert("sss");
});